/*
 * Copyright (C) 2016 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package chuangyuan.ycj.videolibrary.whole;

import android.net.Uri;
import android.os.Handler;
import android.os.SystemClock;
import android.util.Log;

import androidx.annotation.Nullable;

import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ParserException;
import com.google.android.exoplayer2.source.LoadEventInfo;
import com.google.android.exoplayer2.source.MediaLoadData;
import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher;
import com.google.android.exoplayer2.source.hls.HlsDataSourceFactory;
import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist;
import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Variant;
import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist;
import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Part;
import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.RenditionReport;
import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment;
import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist;
import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParser;
import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParserFactory;
import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.HttpDataSource;
import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy;
import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy.LoadErrorInfo;
import com.google.android.exoplayer2.upstream.Loader;
import com.google.android.exoplayer2.upstream.Loader.LoadErrorAction;
import com.google.android.exoplayer2.upstream.ParsingLoadable;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Util;
import com.google.common.collect.Iterables;

import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
import static com.google.android.exoplayer2.util.Util.castNonNull;
import static java.lang.Math.max;

/**
 * Default implementation for {@link HlsPlaylistTracker}.
 */
public final class MyHlsPlaylistTracker
        implements HlsPlaylistTracker, Loader.Callback<ParsingLoadable<HlsPlaylist>> {
    private static final String TAG = "MyHlsPlaylistTracker";

    /**
     * Factory for {@link MyHlsPlaylistTracker} instances.
     */
    public static final Factory FACTORY = MyHlsPlaylistTracker::new;

    /**
     * Default coefficient applied on the target duration of a playlist to determine the amount of
     * time after which an unchanging playlist is considered stuck.
     */
    public static final double DEFAULT_PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT = 3.5;

    private final HlsDataSourceFactory dataSourceFactory;
    private final HlsPlaylistParserFactory playlistParserFactory;
    private final LoadErrorHandlingPolicy loadErrorHandlingPolicy;
    private final HashMap<Uri, MediaPlaylistBundle> playlistBundles;
    private final CopyOnWriteArrayList<PlaylistEventListener> listeners;
    private final double playlistStuckTargetDurationCoefficient;

    @Nullable
    private EventDispatcher eventDispatcher;
    @Nullable
    private Loader initialPlaylistLoader;
    @Nullable
    private Handler playlistRefreshHandler;
    @Nullable
    private PrimaryPlaylistListener primaryPlaylistListener;
    @Nullable
    private HlsMasterPlaylist masterPlaylist;
    @Nullable
    private Uri primaryMediaPlaylistUrl;
    @Nullable
    private HlsMediaPlaylist primaryMediaPlaylistSnapshot;
    private boolean isLive;
    private long initialStartTimeUs;

    /**
     * Creates an instance.
     *
     * @param dataSourceFactory       A factory for {@link DataSource} instances.
     * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy}.
     * @param playlistParserFactory   An {@link HlsPlaylistParserFactory}.
     */
    public MyHlsPlaylistTracker(
            HlsDataSourceFactory dataSourceFactory,
            LoadErrorHandlingPolicy loadErrorHandlingPolicy,
            HlsPlaylistParserFactory playlistParserFactory) {
        this(
                dataSourceFactory,
                loadErrorHandlingPolicy,
                playlistParserFactory,
                DEFAULT_PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT);
    }

    /**
     * Creates an instance.
     *
     * @param dataSourceFactory                      A factory for {@link DataSource} instances.
     * @param loadErrorHandlingPolicy                The {@link LoadErrorHandlingPolicy}.
     * @param playlistParserFactory                  An {@link HlsPlaylistParserFactory}.
     * @param playlistStuckTargetDurationCoefficient A coefficient to apply to the target duration of
     *                                               media playlists in order to determine that a non-changing playlist is stuck. Once a
     *                                               playlist is deemed stuck, a {@link PlaylistStuckException} is thrown via {@link
     *                                               #maybeThrowPlaylistRefreshError(Uri)}.
     */
    public MyHlsPlaylistTracker(
            HlsDataSourceFactory dataSourceFactory,
            LoadErrorHandlingPolicy loadErrorHandlingPolicy,
            HlsPlaylistParserFactory playlistParserFactory,
            double playlistStuckTargetDurationCoefficient) {
        this.dataSourceFactory = dataSourceFactory;
        this.playlistParserFactory = playlistParserFactory;
        this.loadErrorHandlingPolicy = loadErrorHandlingPolicy;
        this.playlistStuckTargetDurationCoefficient = playlistStuckTargetDurationCoefficient;
        listeners = new CopyOnWriteArrayList<>();
        playlistBundles = new HashMap<>();
        initialStartTimeUs = C.TIME_UNSET;
    }

    // HlsPlaylistTracker implementation.

    @Override
    public void start(
            Uri initialPlaylistUri,
            EventDispatcher eventDispatcher,
            PrimaryPlaylistListener primaryPlaylistListener) {
        this.playlistRefreshHandler = Util.createHandlerForCurrentLooper();
        this.eventDispatcher = eventDispatcher;
        this.primaryPlaylistListener = primaryPlaylistListener;
        ParsingLoadable<HlsPlaylist> masterPlaylistLoadable =
                new ParsingLoadable<>(
                        dataSourceFactory.createDataSource(C.DATA_TYPE_MANIFEST),
                        initialPlaylistUri,
                        C.DATA_TYPE_MANIFEST,
                        playlistParserFactory.createPlaylistParser());
        Assertions.checkState(initialPlaylistLoader == null);
        initialPlaylistLoader = new Loader("MyHlsPlaylistTracker:MasterPlaylist");
        long elapsedRealtime =
                initialPlaylistLoader.startLoading(
                        masterPlaylistLoadable,
                        this,
                        loadErrorHandlingPolicy.getMinimumLoadableRetryCount(masterPlaylistLoadable.type));
        eventDispatcher.loadStarted(
                new LoadEventInfo(
                        masterPlaylistLoadable.loadTaskId, masterPlaylistLoadable.dataSpec, elapsedRealtime),
                masterPlaylistLoadable.type);
    }

    @Override
    public void stop() {
        primaryMediaPlaylistUrl = null;
        primaryMediaPlaylistSnapshot = null;
        masterPlaylist = null;
        initialStartTimeUs = C.TIME_UNSET;
        initialPlaylistLoader.release();
        initialPlaylistLoader = null;
        for (MediaPlaylistBundle bundle : playlistBundles.values()) {
            bundle.release();
        }
        playlistRefreshHandler.removeCallbacksAndMessages(null);
        playlistRefreshHandler = null;
        playlistBundles.clear();
    }

    @Override
    public void addListener(PlaylistEventListener listener) {
        checkNotNull(listener);
        listeners.add(listener);
    }

    @Override
    public void removeListener(PlaylistEventListener listener) {
        listeners.remove(listener);
    }

    @Override
    @Nullable
    public HlsMasterPlaylist getMasterPlaylist() {
        return masterPlaylist;
    }

    @Override
    @Nullable
    public HlsMediaPlaylist getPlaylistSnapshot(Uri url, boolean isForPlayback) {
        @Nullable HlsMediaPlaylist snapshot = playlistBundles.get(url).getPlaylistSnapshot();
        if (snapshot != null && isForPlayback) {
            maybeSetPrimaryUrl(url);
        }
        return snapshot;
    }

    @Override
    public long getInitialStartTimeUs() {
        return initialStartTimeUs;
    }

    @Override
    public boolean isSnapshotValid(Uri url) {
        return playlistBundles.get(url).isSnapshotValid();
    }

    @Override
    public void maybeThrowPrimaryPlaylistRefreshError() throws IOException {
        if (initialPlaylistLoader != null) {
            initialPlaylistLoader.maybeThrowError();
        }
        if (primaryMediaPlaylistUrl != null) {
            maybeThrowPlaylistRefreshError(primaryMediaPlaylistUrl);
        }
    }

    @Override
    public void maybeThrowPlaylistRefreshError(Uri url) throws IOException {
        if (!isLive) {
            Log.e(TAG, "maybeThrowPlaylistRefreshError: ");
            return;
        }
        playlistBundles.get(url).maybeThrowPlaylistRefreshError();
    }

    @Override
    public void refreshPlaylist(Uri url) {
        playlistBundles.get(url).loadPlaylist();
    }

    @Override
    public boolean isLive() {
        return isLive;
    }

    @Override
    public boolean excludeMediaPlaylist(Uri playlistUrl, long exclusionDurationMs) {
        @Nullable MediaPlaylistBundle bundle = playlistBundles.get(playlistUrl);
        if (bundle != null) {
            return !bundle.excludePlaylist(exclusionDurationMs);
        }
        return false;
    }

    // Loader.Callback implementation.

    @Override
    public void onLoadCompleted(
            ParsingLoadable<HlsPlaylist> loadable, long elapsedRealtimeMs, long loadDurationMs) {
        HlsPlaylist result = loadable.getResult();
        HlsMasterPlaylist masterPlaylist;
        boolean isMediaPlaylist = result instanceof HlsMediaPlaylist;
        if (isMediaPlaylist) {
            masterPlaylist = HlsMasterPlaylist.createSingleVariantMasterPlaylist(result.baseUri);
        } else /* result instanceof HlsMasterPlaylist */ {
            masterPlaylist = (HlsMasterPlaylist) result;
        }
        this.masterPlaylist = masterPlaylist;
        primaryMediaPlaylistUrl = masterPlaylist.variants.get(0).url;
        // Add a temporary playlist listener for loading the first primary playlist.
        listeners.add(new FirstPrimaryMediaPlaylistListener());
        createBundles(masterPlaylist.mediaPlaylistUrls);
        LoadEventInfo loadEventInfo =
                new LoadEventInfo(
                        loadable.loadTaskId,
                        loadable.dataSpec,
                        loadable.getUri(),
                        loadable.getResponseHeaders(),
                        elapsedRealtimeMs,
                        loadDurationMs,
                        loadable.bytesLoaded());
        MediaPlaylistBundle primaryBundle = playlistBundles.get(primaryMediaPlaylistUrl);
        if (isMediaPlaylist) {
            // We don't need to load the playlist again. We can use the same result.
            primaryBundle.processLoadedPlaylist((HlsMediaPlaylist) result, loadEventInfo);
        } else {
            primaryBundle.loadPlaylist();
        }
        loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId);
        eventDispatcher.loadCompleted(loadEventInfo, C.DATA_TYPE_MANIFEST);
    }

    @Override
    public void onLoadCanceled(
            ParsingLoadable<HlsPlaylist> loadable,
            long elapsedRealtimeMs,
            long loadDurationMs,
            boolean released) {
        LoadEventInfo loadEventInfo =
                new LoadEventInfo(
                        loadable.loadTaskId,
                        loadable.dataSpec,
                        loadable.getUri(),
                        loadable.getResponseHeaders(),
                        elapsedRealtimeMs,
                        loadDurationMs,
                        loadable.bytesLoaded());
        loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId);
        eventDispatcher.loadCanceled(loadEventInfo, C.DATA_TYPE_MANIFEST);
    }

    @Override
    public LoadErrorAction onLoadError(
            ParsingLoadable<HlsPlaylist> loadable,
            long elapsedRealtimeMs,
            long loadDurationMs,
            IOException error,
            int errorCount) {
        LoadEventInfo loadEventInfo =
                new LoadEventInfo(
                        loadable.loadTaskId,
                        loadable.dataSpec,
                        loadable.getUri(),
                        loadable.getResponseHeaders(),
                        elapsedRealtimeMs,
                        loadDurationMs,
                        loadable.bytesLoaded());
        MediaLoadData mediaLoadData = new MediaLoadData(loadable.type);
        long retryDelayMs =
                loadErrorHandlingPolicy.getRetryDelayMsFor(
                        new LoadErrorInfo(loadEventInfo, mediaLoadData, error, errorCount));
        boolean isFatal = retryDelayMs == C.TIME_UNSET;
        eventDispatcher.loadError(loadEventInfo, loadable.type, error, isFatal);
        if (isFatal) {
            loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId);
        }
        return isFatal
                ? Loader.DONT_RETRY_FATAL
                : Loader.createRetryAction(/* resetErrorCount= */ false, retryDelayMs);
    }

    // Internal methods.

    private boolean maybeSelectNewPrimaryUrl() {
        List<Variant> variants = masterPlaylist.variants;
        int variantsSize = variants.size();
        long currentTimeMs = SystemClock.elapsedRealtime();
        for (int i = 0; i < variantsSize; i++) {
            MediaPlaylistBundle bundle = checkNotNull(playlistBundles.get(variants.get(i).url));
            if (currentTimeMs > bundle.excludeUntilMs) {
                primaryMediaPlaylistUrl = bundle.playlistUrl;
                bundle.loadPlaylistInternal(getRequestUriForPrimaryChange(primaryMediaPlaylistUrl));
                return true;
            }
        }
        return false;
    }

    private void maybeSetPrimaryUrl(Uri url) {
        if (url.equals(primaryMediaPlaylistUrl)
                || !isVariantUrl(url)
                || (primaryMediaPlaylistSnapshot != null && primaryMediaPlaylistSnapshot.hasEndTag)) {
            // Ignore if the primary media playlist URL is unchanged, if the media playlist is not
            // referenced directly by a variant, or it the last primary snapshot contains an end tag.
            return;
        }
        primaryMediaPlaylistUrl = url;
        MediaPlaylistBundle newPrimaryBundle = playlistBundles.get(primaryMediaPlaylistUrl);
        @Nullable HlsMediaPlaylist newPrimarySnapshot = newPrimaryBundle.playlistSnapshot;
        if (newPrimarySnapshot != null && newPrimarySnapshot.hasEndTag) {
            primaryMediaPlaylistSnapshot = newPrimarySnapshot;
            primaryPlaylistListener.onPrimaryPlaylistRefreshed(newPrimarySnapshot);
        } else {
            // The snapshot for the new primary media playlist URL may be stale. Defer updating the
            // primary snapshot until after we've refreshed it.
            newPrimaryBundle.loadPlaylistInternal(getRequestUriForPrimaryChange(url));
        }
    }

    private Uri getRequestUriForPrimaryChange(Uri newPrimaryPlaylistUri) {
        if (primaryMediaPlaylistSnapshot != null
                && primaryMediaPlaylistSnapshot.serverControl.canBlockReload) {
            @Nullable
            RenditionReport renditionReport =
                    primaryMediaPlaylistSnapshot.renditionReports.get(newPrimaryPlaylistUri);
            if (renditionReport != null) {
                Uri.Builder uriBuilder = newPrimaryPlaylistUri.buildUpon();
                uriBuilder.appendQueryParameter(
                        MediaPlaylistBundle.BLOCK_MSN_PARAM, String.valueOf(renditionReport.lastMediaSequence));
                if (renditionReport.lastPartIndex != C.INDEX_UNSET) {
                    uriBuilder.appendQueryParameter(
                            MediaPlaylistBundle.BLOCK_PART_PARAM, String.valueOf(renditionReport.lastPartIndex));
                }
                return uriBuilder.build();
            }
        }
        return newPrimaryPlaylistUri;
    }

    /**
     * Returns whether any of the variants in the master playlist have the specified playlist URL.
     */
    private boolean isVariantUrl(Uri playlistUrl) {
        List<Variant> variants = masterPlaylist.variants;
        for (int i = 0; i < variants.size(); i++) {
            if (playlistUrl.equals(variants.get(i).url)) {
                return true;
            }
        }
        return false;
    }

    private void createBundles(List<Uri> urls) {
        int listSize = urls.size();
        for (int i = 0; i < listSize; i++) {
            Uri url = urls.get(i);
            MediaPlaylistBundle bundle = new MediaPlaylistBundle(url);
            playlistBundles.put(url, bundle);
        }
    }

    /**
     * Called by the bundles when a snapshot changes.
     *
     * @param url         The url of the playlist.
     * @param newSnapshot The new snapshot.
     */
    private void onPlaylistUpdated(Uri url, HlsMediaPlaylist newSnapshot) {
        if (url.equals(primaryMediaPlaylistUrl)) {
            if (primaryMediaPlaylistSnapshot == null) {
                // This is the first primary url snapshot.
                isLive = !newSnapshot.hasEndTag;
                initialStartTimeUs = newSnapshot.startTimeUs;
            }
            primaryMediaPlaylistSnapshot = newSnapshot;
            primaryPlaylistListener.onPrimaryPlaylistRefreshed(newSnapshot);
        }
        for (PlaylistEventListener listener : listeners) {
            listener.onPlaylistChanged();
        }
    }

    private boolean notifyPlaylistError(
            Uri playlistUrl, LoadErrorInfo loadErrorInfo, boolean forceRetry) {
        boolean anyExclusionFailed = false;
        for (PlaylistEventListener listener : listeners) {
            anyExclusionFailed |= !listener.onPlaylistError(playlistUrl, loadErrorInfo, forceRetry);
        }
        return anyExclusionFailed;
    }

    private HlsMediaPlaylist getLatestPlaylistSnapshot(
            @Nullable HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) {
        if (!loadedPlaylist.isNewerThan(oldPlaylist)) {
            if (loadedPlaylist.hasEndTag) {
                // If the loaded playlist has an end tag but is not newer than the old playlist then we have
                // an inconsistent state. This is typically caused by the server incorrectly resetting the
                // media sequence when appending the end tag. We resolve this case as best we can by
                // returning the old playlist with the end tag appended.
                return oldPlaylist.copyWithEndTag();
            } else {
                return oldPlaylist;
            }
        }
        long startTimeUs = getLoadedPlaylistStartTimeUs(oldPlaylist, loadedPlaylist);
        int discontinuitySequence = getLoadedPlaylistDiscontinuitySequence(oldPlaylist, loadedPlaylist);
        return loadedPlaylist.copyWith(startTimeUs, discontinuitySequence);
    }

    private long getLoadedPlaylistStartTimeUs(
            @Nullable HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) {
        if (loadedPlaylist.hasProgramDateTime) {
            return loadedPlaylist.startTimeUs;
        }
        long primarySnapshotStartTimeUs =
                primaryMediaPlaylistSnapshot != null ? primaryMediaPlaylistSnapshot.startTimeUs : 0;
        if (oldPlaylist == null) {
            return primarySnapshotStartTimeUs;
        }
        int oldPlaylistSize = oldPlaylist.segments.size();
        Segment firstOldOverlappingSegment = getFirstOldOverlappingSegment(oldPlaylist, loadedPlaylist);
        if (firstOldOverlappingSegment != null) {
            return oldPlaylist.startTimeUs + firstOldOverlappingSegment.relativeStartTimeUs;
        } else if (oldPlaylistSize == loadedPlaylist.mediaSequence - oldPlaylist.mediaSequence) {
            return oldPlaylist.getEndTimeUs();
        } else {
            // No segments overlap, we assume the new playlist start coincides with the primary playlist.
            return primarySnapshotStartTimeUs;
        }
    }

    private int getLoadedPlaylistDiscontinuitySequence(
            @Nullable HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) {
        if (loadedPlaylist.hasDiscontinuitySequence) {
            return loadedPlaylist.discontinuitySequence;
        }
        // TODO: Improve cross-playlist discontinuity adjustment.
        int primaryUrlDiscontinuitySequence =
                primaryMediaPlaylistSnapshot != null
                        ? primaryMediaPlaylistSnapshot.discontinuitySequence
                        : 0;
        if (oldPlaylist == null) {
            return primaryUrlDiscontinuitySequence;
        }
        Segment firstOldOverlappingSegment = getFirstOldOverlappingSegment(oldPlaylist, loadedPlaylist);
        if (firstOldOverlappingSegment != null) {
            return oldPlaylist.discontinuitySequence
                    + firstOldOverlappingSegment.relativeDiscontinuitySequence
                    - loadedPlaylist.segments.get(0).relativeDiscontinuitySequence;
        }
        return primaryUrlDiscontinuitySequence;
    }

    private static Segment getFirstOldOverlappingSegment(
            HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) {
        int mediaSequenceOffset = (int) (loadedPlaylist.mediaSequence - oldPlaylist.mediaSequence);
        List<Segment> oldSegments = oldPlaylist.segments;
        return mediaSequenceOffset < oldSegments.size() ? oldSegments.get(mediaSequenceOffset) : null;
    }

    /**
     * Holds all information related to a specific Media Playlist.
     */
    private final class MediaPlaylistBundle implements Loader.Callback<ParsingLoadable<HlsPlaylist>> {

        private static final String BLOCK_MSN_PARAM = "_HLS_msn";
        private static final String BLOCK_PART_PARAM = "_HLS_part";
        private static final String SKIP_PARAM = "_HLS_skip";

        private final Uri playlistUrl;
        private final Loader mediaPlaylistLoader;
        private final DataSource mediaPlaylistDataSource;

        @Nullable
        private HlsMediaPlaylist playlistSnapshot;
        private long lastSnapshotLoadMs;
        private long lastSnapshotChangeMs;
        private long earliestNextLoadTimeMs;
        private long excludeUntilMs;
        private boolean loadPending;
        @Nullable
        private IOException playlistError;

        public MediaPlaylistBundle(Uri playlistUrl) {
            this.playlistUrl = playlistUrl;
            mediaPlaylistLoader = new Loader("MyHlsPlaylistTracker:MediaPlaylist");
            mediaPlaylistDataSource = dataSourceFactory.createDataSource(C.DATA_TYPE_MANIFEST);
        }

        @Nullable
        public HlsMediaPlaylist getPlaylistSnapshot() {
            return playlistSnapshot;
        }

        public boolean isSnapshotValid() {
            if (playlistSnapshot == null) {
                return false;
            }
            long currentTimeMs = SystemClock.elapsedRealtime();
            long snapshotValidityDurationMs = max(30000, C.usToMs(playlistSnapshot.durationUs));
            return playlistSnapshot.hasEndTag
                    || playlistSnapshot.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_EVENT
                    || playlistSnapshot.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_VOD
                    || lastSnapshotLoadMs + snapshotValidityDurationMs > currentTimeMs;
        }

        public void loadPlaylist() {
            loadPlaylistInternal(playlistUrl);
        }

        public void maybeThrowPlaylistRefreshError() throws IOException {
            mediaPlaylistLoader.maybeThrowError();
            if (playlistError != null) {
                throw playlistError;
            }
        }

        public void release() {
            mediaPlaylistLoader.release();
        }

        // Loader.Callback implementation.

        @Override
        public void onLoadCompleted(
                ParsingLoadable<HlsPlaylist> loadable, long elapsedRealtimeMs, long loadDurationMs) {
            @Nullable HlsPlaylist result = loadable.getResult();
            LoadEventInfo loadEventInfo =
                    new LoadEventInfo(
                            loadable.loadTaskId,
                            loadable.dataSpec,
                            loadable.getUri(),
                            loadable.getResponseHeaders(),
                            elapsedRealtimeMs,
                            loadDurationMs,
                            loadable.bytesLoaded());
            if (result instanceof HlsMediaPlaylist) {
                processLoadedPlaylist((HlsMediaPlaylist) result, loadEventInfo);
                eventDispatcher.loadCompleted(loadEventInfo, C.DATA_TYPE_MANIFEST);
            } else {
                playlistError =
                        ParserException.createForMalformedManifest(
                                "Loaded playlist has unexpected type.", /* cause= */ null);
                eventDispatcher.loadError(
                        loadEventInfo, C.DATA_TYPE_MANIFEST, playlistError, /* wasCanceled= */ true);
            }
            loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId);
        }

        @Override
        public void onLoadCanceled(
                ParsingLoadable<HlsPlaylist> loadable,
                long elapsedRealtimeMs,
                long loadDurationMs,
                boolean released) {
            LoadEventInfo loadEventInfo =
                    new LoadEventInfo(
                            loadable.loadTaskId,
                            loadable.dataSpec,
                            loadable.getUri(),
                            loadable.getResponseHeaders(),
                            elapsedRealtimeMs,
                            loadDurationMs,
                            loadable.bytesLoaded());
            loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId);
            eventDispatcher.loadCanceled(loadEventInfo, C.DATA_TYPE_MANIFEST);
        }

        @Override
        public LoadErrorAction onLoadError(
                ParsingLoadable<HlsPlaylist> loadable,
                long elapsedRealtimeMs,
                long loadDurationMs,
                IOException error,
                int errorCount) {
            LoadEventInfo loadEventInfo =
                    new LoadEventInfo(
                            loadable.loadTaskId,
                            loadable.dataSpec,
                            loadable.getUri(),
                            loadable.getResponseHeaders(),
                            elapsedRealtimeMs,
                            loadDurationMs,
                            loadable.bytesLoaded());
            boolean isBlockingRequest = loadable.getUri().getQueryParameter(BLOCK_MSN_PARAM) != null;
            boolean deltaUpdateFailed = error instanceof HlsPlaylistParser.DeltaUpdateException;
            if (isBlockingRequest || deltaUpdateFailed) {
                int responseCode = Integer.MAX_VALUE;
                if (error instanceof HttpDataSource.InvalidResponseCodeException) {
                    responseCode = ((HttpDataSource.InvalidResponseCodeException) error).responseCode;
                }
                if (deltaUpdateFailed || responseCode == 400 || responseCode == 503) {
                    // Intercept failed delta updates and blocking requests producing a Bad Request (400) and
                    // Service Unavailable (503). In such cases, force a full, non-blocking request (see RFC
                    // 8216, section 6.2.5.2 and 6.3.7).
                    earliestNextLoadTimeMs = SystemClock.elapsedRealtime();
                    loadPlaylist();
                    castNonNull(eventDispatcher)
                            .loadError(loadEventInfo, loadable.type, error, /* wasCanceled= */ true);
                    return Loader.DONT_RETRY;
                }
            }
            MediaLoadData mediaLoadData = new MediaLoadData(loadable.type);
            LoadErrorInfo loadErrorInfo =
                    new LoadErrorInfo(loadEventInfo, mediaLoadData, error, errorCount);
            boolean exclusionFailed =
                    notifyPlaylistError(playlistUrl, loadErrorInfo, /* forceRetry= */ false);
            LoadErrorAction loadErrorAction;
            if (exclusionFailed) {
                long retryDelay = loadErrorHandlingPolicy.getRetryDelayMsFor(loadErrorInfo);
                loadErrorAction =
                        retryDelay != C.TIME_UNSET
                                ? Loader.createRetryAction(false, retryDelay)
                                : Loader.DONT_RETRY_FATAL;
            } else {
                loadErrorAction = Loader.DONT_RETRY;
            }

            boolean wasCanceled = !loadErrorAction.isRetry();
            eventDispatcher.loadError(loadEventInfo, loadable.type, error, wasCanceled);
            if (wasCanceled) {
                loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId);
            }
            return loadErrorAction;
        }

        // Internal methods.

        private void loadPlaylistInternal(Uri playlistRequestUri) {
            excludeUntilMs = 0;
            if (loadPending || mediaPlaylistLoader.isLoading() || mediaPlaylistLoader.hasFatalError()) {
                // Load already pending, in progress, or a fatal error has been encountered. Do nothing.
                return;
            }
            long currentTimeMs = SystemClock.elapsedRealtime();
            if (currentTimeMs < earliestNextLoadTimeMs) {
                loadPending = true;
                playlistRefreshHandler.postDelayed(
                        () -> {
                            loadPending = false;
                            loadPlaylistImmediately(playlistRequestUri);
                        },
                        earliestNextLoadTimeMs - currentTimeMs);
            } else {
                loadPlaylistImmediately(playlistRequestUri);
            }
        }

        private void loadPlaylistImmediately(Uri playlistRequestUri) {
            ParsingLoadable.Parser<HlsPlaylist> mediaPlaylistParser =
                    playlistParserFactory.createPlaylistParser(masterPlaylist, playlistSnapshot);
            ParsingLoadable<HlsPlaylist> mediaPlaylistLoadable =
                    new ParsingLoadable<>(
                            mediaPlaylistDataSource,
                            playlistRequestUri,
                            C.DATA_TYPE_MANIFEST,
                            mediaPlaylistParser);
            long elapsedRealtime =
                    mediaPlaylistLoader.startLoading(
                            mediaPlaylistLoadable,
                            /* callback= */ this,
                            loadErrorHandlingPolicy.getMinimumLoadableRetryCount(mediaPlaylistLoadable.type));
            eventDispatcher.loadStarted(
                    new LoadEventInfo(
                            mediaPlaylistLoadable.loadTaskId, mediaPlaylistLoadable.dataSpec, elapsedRealtime),
                    mediaPlaylistLoadable.type);
        }

        private void processLoadedPlaylist(
                HlsMediaPlaylist loadedPlaylist, LoadEventInfo loadEventInfo) {
            @Nullable HlsMediaPlaylist oldPlaylist = playlistSnapshot;
            long currentTimeMs = SystemClock.elapsedRealtime();
            lastSnapshotLoadMs = currentTimeMs;
            playlistSnapshot = getLatestPlaylistSnapshot(oldPlaylist, loadedPlaylist);
            if (playlistSnapshot != oldPlaylist) {
                playlistError = null;
                lastSnapshotChangeMs = currentTimeMs;
                onPlaylistUpdated(playlistUrl, playlistSnapshot);
            } else if (!playlistSnapshot.hasEndTag) {
                boolean forceRetry = false;
                @Nullable IOException playlistError = null;
                if (loadedPlaylist.mediaSequence + loadedPlaylist.segments.size()
                        < playlistSnapshot.mediaSequence) {
                    // TODO: Allow customization of playlist resets handling.
                    // The media sequence jumped backwards. The server has probably reset. We do not try
                    // excluding in this case.
                    forceRetry = true;
                    playlistError = new PlaylistResetException(playlistUrl);
                } else if (currentTimeMs - lastSnapshotChangeMs
                        > C.usToMs(playlistSnapshot.targetDurationUs)
                        * playlistStuckTargetDurationCoefficient) {
                    // TODO: Allow customization of stuck playlists handling.
                    playlistError = new PlaylistStuckException(playlistUrl);
                }
                if (playlistError != null) {
                    this.playlistError = playlistError;
                    notifyPlaylistError(
                            playlistUrl,
                            new LoadErrorInfo(
                                    loadEventInfo,
                                    new MediaLoadData(C.DATA_TYPE_MANIFEST),
                                    playlistError,
                                    /* errorCount= */ 1),
                            forceRetry);
                }
            }
            long durationUntilNextLoadUs = 0L;
            if (!playlistSnapshot.serverControl.canBlockReload) {
                // If blocking requests are not supported, do not allow the playlist to load again within
                // the target duration if we obtained a new snapshot, or half the target duration otherwise.
                durationUntilNextLoadUs =
                        playlistSnapshot != oldPlaylist
                                ? playlistSnapshot.targetDurationUs
                                : (playlistSnapshot.targetDurationUs / 2);
            }
            earliestNextLoadTimeMs = currentTimeMs + C.usToMs(durationUntilNextLoadUs);
            // Schedule a load if this is the primary playlist or a playlist of a low-latency stream and
            // it doesn't have an end tag. Else the next load will be scheduled when refreshPlaylist is
            // called, or when this playlist becomes the primary.
            boolean scheduleLoad =
                    playlistSnapshot.partTargetDurationUs != C.TIME_UNSET
                            || playlistUrl.equals(primaryMediaPlaylistUrl);
            if (scheduleLoad && !playlistSnapshot.hasEndTag) {
                loadPlaylistInternal(getMediaPlaylistUriForReload());
            }
        }

        private Uri getMediaPlaylistUriForReload() {
            if (playlistSnapshot == null
                    || (playlistSnapshot.serverControl.skipUntilUs == C.TIME_UNSET
                    && !playlistSnapshot.serverControl.canBlockReload)) {
                return playlistUrl;
            }
            Uri.Builder uriBuilder = playlistUrl.buildUpon();
            if (playlistSnapshot.serverControl.canBlockReload) {
                long targetMediaSequence =
                        playlistSnapshot.mediaSequence + playlistSnapshot.segments.size();
                uriBuilder.appendQueryParameter(BLOCK_MSN_PARAM, String.valueOf(targetMediaSequence));
                if (playlistSnapshot.partTargetDurationUs != C.TIME_UNSET) {
                    List<Part> trailingParts = playlistSnapshot.trailingParts;
                    int targetPartIndex = trailingParts.size();
                    if (!trailingParts.isEmpty() && Iterables.getLast(trailingParts).isPreload) {
                        // Ignore the preload part.
                        targetPartIndex--;
                    }
                    uriBuilder.appendQueryParameter(BLOCK_PART_PARAM, String.valueOf(targetPartIndex));
                }
            }
            if (playlistSnapshot.serverControl.skipUntilUs != C.TIME_UNSET) {
                uriBuilder.appendQueryParameter(
                        SKIP_PARAM, playlistSnapshot.serverControl.canSkipDateRanges ? "v2" : "YES");
            }
            return uriBuilder.build();
        }

        /**
         * Excludes the playlist.
         *
         * @param exclusionDurationMs The number of milliseconds for which the playlist should be
         *                            excluded.
         * @return Whether the playlist is the primary, despite being excluded.
         */
        private boolean excludePlaylist(long exclusionDurationMs) {
            excludeUntilMs = SystemClock.elapsedRealtime() + exclusionDurationMs;
            return playlistUrl.equals(primaryMediaPlaylistUrl) && !maybeSelectNewPrimaryUrl();
        }
    }

    /**
     * Takes care of handling load errors of the first media playlist and applies exclusion according
     * to the {@link LoadErrorHandlingPolicy} before the first media period has been created and
     * prepared.
     */
    private class FirstPrimaryMediaPlaylistListener implements PlaylistEventListener {

        @Override
        public void onPlaylistChanged() {
            // Remove the temporary playlist listener that is waiting for the first playlist only.
            listeners.remove(this);
        }

        @Override
        public boolean onPlaylistError(Uri url, LoadErrorInfo loadErrorInfo, boolean forceRetry) {
            if (primaryMediaPlaylistSnapshot == null) {
                long nowMs = SystemClock.elapsedRealtime();
                int variantExclusionCounter = 0;
                List<Variant> variants = castNonNull(masterPlaylist).variants;
                for (int i = 0; i < variants.size(); i++) {
                    @Nullable
                    MediaPlaylistBundle mediaPlaylistBundle = playlistBundles.get(variants.get(i).url);
                    if (mediaPlaylistBundle != null && nowMs < mediaPlaylistBundle.excludeUntilMs) {
                        variantExclusionCounter++;
                    }
                }
                LoadErrorHandlingPolicy.FallbackOptions fallbackOptions =
                        new LoadErrorHandlingPolicy.FallbackOptions(
                                /* numberOfLocations= */ 1,
                                /* numberOfExcludedLocations= */ 0,
                                /* numberOfTracks= */ masterPlaylist.variants.size(),
                                /* numberOfExcludedTracks= */ variantExclusionCounter);
                @Nullable
                LoadErrorHandlingPolicy.FallbackSelection fallbackSelection =
                        loadErrorHandlingPolicy.getFallbackSelectionFor(fallbackOptions, loadErrorInfo);
                if (fallbackSelection != null
                        && fallbackSelection.type == LoadErrorHandlingPolicy.FALLBACK_TYPE_TRACK) {
                    @Nullable MediaPlaylistBundle mediaPlaylistBundle = playlistBundles.get(url);
                    if (mediaPlaylistBundle != null) {
                        mediaPlaylistBundle.excludePlaylist(fallbackSelection.exclusionDurationMs);
                    }
                }
            }
            return false;
        }
    }
}
